Анализируем данные приложения "Ненужные вещи", в котором пользователи продают и покупают вещи по принципу доски объявлений.
Наша цель - Получить на основе поведения пользователей гипотезы о том как можно было бы улучшить приложение с точки зрения пользовательского опыта, чтобы владельцы приложения могли иметь возможность управлять вовлеченностью пользователей.
Для достижения поставленной цели решить следующие задачи:
Для достижения нашей задачи следует провести следующие действия:
Приступим к анализу данных.
По проведенному исследованию представлены следующие визуализации:
pip install matplotlib --upgrade
Requirement already satisfied: matplotlib in c:\users\kuzne\anaconda3\lib\site-packages (3.8.2) Requirement already satisfied: fonttools>=4.22.0 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (4.25.0) Requirement already satisfied: pillow>=8 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (9.4.0) Requirement already satisfied: contourpy>=1.0.1 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (1.0.5) Requirement already satisfied: numpy<2,>=1.21 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (1.23.5) Requirement already satisfied: cycler>=0.10 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (0.11.0) Requirement already satisfied: kiwisolver>=1.3.1 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (1.4.4) Requirement already satisfied: pyparsing>=2.3.1 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (3.0.9) Requirement already satisfied: python-dateutil>=2.7 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (2.8.2) Requirement already satisfied: packaging>=20.0 in c:\users\kuzne\anaconda3\lib\site-packages (from matplotlib) (22.0) Requirement already satisfied: six>=1.5 in c:\users\kuzne\anaconda3\lib\site-packages (from python-dateutil>=2.7->matplotlib) (1.16.0) Note: you may need to restart the kernel to use updated packages.
import pandas as pd
import datetime as dt
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import seaborn as sns
from scipy import stats as st
import numpy as np
import math as mth
import warnings
warnings.filterwarnings("ignore")
from plotly import graph_objects as go
from plotly.subplots import make_subplots
Приведем все графики к единому стилю
sns.set_style(style='ticks')
У нас есть две таблицы:
mobile sources - которая содержит информацию о пользователях,mobile dataset - содержит информацию о действиях в приложении.Рассмотрим их подробнее
mobile_sources = pd.read_csv('mobile_sources.csv')
mobile_dataset = pd.read_csv('mobile_dataset.csv')
#Зададим ограничения на вывод колонок и количество символов - в данном случае "смягчим"
pd.set_option('display.max_columns', None)
pd.options.display.max_colwidth = 100
display(mobile_sources, mobile_dataset)
| userId | source | |
|---|---|---|
| 0 | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 1 | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
| 2 | 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 | yandex |
| 3 | d9b06b47-0f36-419b-bbb0-3533e582a6cb | other |
| 4 | f32e1e2a-3027-4693-b793-b7b3ff274439 | |
| ... | ... | ... |
| 4288 | b86fe56e-f2de-4f8a-b192-cd89a37ecd41 | yandex |
| 4289 | 424c0ae1-3ea3-4f1e-a814-6bac73e48ab1 | yandex |
| 4290 | 437a4cd4-9ba9-457f-8614-d142bc48fbeb | yandex |
| 4291 | c10055f0-0b47-477a-869e-d391b31fdf8f | yandex |
| 4292 | d157bffc-264d-4464-8220-1cc0c42f43a9 |
4293 rows × 2 columns
| event.time | event.name | user.id | |
|---|---|---|---|
| 0 | 2019-10-07 00:00:00.431357 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 1 | 2019-10-07 00:00:01.236320 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 2 | 2019-10-07 00:00:02.245341 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| 3 | 2019-10-07 00:00:07.039334 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 4 | 2019-10-07 00:00:56.319813 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| ... | ... | ... | ... |
| 74192 | 2019-11-03 23:53:29.534986 | tips_show | 28fccdf4-7b9e-42f5-bc73-439a265f20e9 |
| 74193 | 2019-11-03 23:54:00.407086 | tips_show | 28fccdf4-7b9e-42f5-bc73-439a265f20e9 |
| 74194 | 2019-11-03 23:56:57.041825 | search_1 | 20850c8f-4135-4059-b13b-198d3ac59902 |
| 74195 | 2019-11-03 23:57:06.232189 | tips_show | 28fccdf4-7b9e-42f5-bc73-439a265f20e9 |
| 74196 | 2019-11-03 23:58:12.532487 | tips_show | 28fccdf4-7b9e-42f5-bc73-439a265f20e9 |
74197 rows × 3 columns
В наших датасетах есть следующие данные:
mobile_sources:userId — идентификатор пользователя,source — источник, с которого пользователь установил приложение.mobile_dataset:event.time — время совершения действия,user.id — идентификатор пользователя,event.name — действие пользователя.Пользователи могли совершать следующие действия:
advert_open — открыл карточки объявления,photos_show — просмотрел фотографий в объявлении,tips_show — увидел рекомендованные объявления,tips_click — кликнул по рекомендованному объявлению,contacts_show и show_contacts — посмотрел номер телефона,contacts_call — позвонил по номеру из объявления,map — открыл карту объявлений,search_1 — search_7 — разные действия, связанные с поиском по сайту,favorites_add — добавил объявление в избранное.Рассмотрим данные внимательнее.
print(mobile_sources.info(), mobile_dataset.info());
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4293 entries, 0 to 4292 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 userId 4293 non-null object 1 source 4293 non-null object dtypes: object(2) memory usage: 67.2+ KB <class 'pandas.core.frame.DataFrame'> RangeIndex: 74197 entries, 0 to 74196 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event.time 74197 non-null object 1 event.name 74197 non-null object 2 user.id 74197 non-null object dtypes: object(3) memory usage: 1.7+ MB None None
mobile_sources есть 4293 строки, и, скорее всего, это и есть число пользователей приложения. В обеих колонках содержатся данные типа object, что соответствует записям, которые мы увидели выше.mobile_dataset есть 74197 строк. Данные во всех столбцах также записаны как тип object. Однако, в колонке event.time содержится информация о времени.event.timeПриступим к предоработке.
Для того, чтобы провести корректный исследовательский анализ и в дальнейшем ответить на поставленные заказчиком вопросы, приведём наши данные в порядок, чтобы было удобно с ними работать.
Первое, что мы сделаем - приведем столбцы к единому написанию, а именно в формат snake_case. Это следует сделать в обеих таблицах, которыми мы располагаем.
mobile_dataset.columns = mobile_dataset.columns.str.replace('.', '_')
mobile_sources.columns = ['user_id','source']
display(mobile_dataset.head(), mobile_sources.head())
| event_time | event_name | user_id | |
|---|---|---|---|
| 0 | 2019-10-07 00:00:00.431357 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 1 | 2019-10-07 00:00:01.236320 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 2 | 2019-10-07 00:00:02.245341 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| 3 | 2019-10-07 00:00:07.039334 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 4 | 2019-10-07 00:00:56.319813 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| user_id | source | |
|---|---|---|
| 0 | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 1 | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
| 2 | 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 | yandex |
| 3 | d9b06b47-0f36-419b-bbb0-3533e582a6cb | other |
| 4 | f32e1e2a-3027-4693-b793-b7b3ff274439 |
Теперь проведем проверку наличия пропусков и решим, как мы можем их обработать, если потребуется.
display('mobile_dataset', pd.DataFrame(mobile_dataset.isna().sum()), 'mobile_sources',
pd.DataFrame(mobile_sources.isna().sum()))
'mobile_dataset'
| 0 | |
|---|---|
| event_time | 0 |
| event_name | 0 |
| user_id | 0 |
'mobile_sources'
| 0 | |
|---|---|
| user_id | 0 |
| source | 0 |
Первое предположение о том, что у нас нет пропусков оказалось верным. Пропусков нет и это замечательно, продолжим предобработку.
print('Количество полных явных дубликатов в таблице mobile_dataset:',
mobile_dataset.duplicated().sum(),
'\nКоличество полных явных дубликатов в таблице mobile_sources:',
mobile_sources.duplicated().sum())
Количество полных явных дубликатов в таблице mobile_dataset: 0 Количество полных явных дубликатов в таблице mobile_sources: 0
Явных полных дубликатов в обеих таблицах нет, проверим, есть ли неполные явные дубликаты в таблице mobile_sources, вдруг у нас есть пользователь, которому соответствует два источника.
mobile_sources.duplicated(subset='user_id').sum()
0
Нет, все пользователи встречаются лишь один раз, а значит каждому пользователю соответствует только один источник.
Теперь нам стоит привести разночтения в наименовании некоторых событий к единому виду, так как они создают некоторые неявные дубликаты, а также их сложно обрабатывать. Нас инстересуют все события с search_, так как их 7 разных, и наше целевое событие тоже имеет "дубликат". Приведем их к виду search и contacts_show соответственно. А также проверим, нет ли разночтений и опечаток среди наших источников пользователей.
mobile_dataset['event_name'] = mobile_dataset['event_name'].replace(regex=r'^search_\d+',
value='search')
mobile_dataset['event_name'] = mobile_dataset['event_name'].replace('show_contacts',
'contacts_show')
print('Уникальные события:', mobile_dataset['event_name'].unique(),
'\nУникальные источники:', mobile_sources['source'].unique())
Уникальные события: ['advert_open' 'tips_show' 'map' 'contacts_show' 'search' 'tips_click' 'photos_show' 'favorites_add' 'contacts_call'] Уникальные источники: ['other' 'yandex' 'google']
Итак, больше разночтений и неявных дубликатов нет. Еще раз проверим, не привели ли наши действия к появлению полных явных дубликатов.
print('Количество полных явных дубликатов в таблице mobile_dataset:',
mobile_dataset.duplicated().sum())
Количество полных явных дубликатов в таблице mobile_dataset: 0
Отлично, пропусков и дубликатов нет, переходим к следующему пункту.
В таблице mobile_dataset есть колонка, в которой содержится дата и время, когда наши пользователи совершали действия. Стоит привести её к правильному типу данных.
mobile_dataset['event_time'] = pd.to_datetime(mobile_dataset['event_time'], unit='ns')
mobile_dataset.dtypes
event_time datetime64[ns] event_name object user_id object dtype: object
Отлично, теперь можно объединить нашу таблицу в единый датасет и приступить к исследовательскому анализу.
Для комфортной работы получим из двух наших таблиц одну. Сделаем это при помощи функции merge и будем объединять по идентификаторам пользователей.
dataset = mobile_dataset.merge(mobile_sources, on='user_id')
dataset.sample(5)
| event_time | event_name | user_id | source | |
|---|---|---|---|---|
| 19269 | 2019-10-12 10:08:36.561814 | tips_show | b3b62f2c-e603-4490-8e7a-cc60697b6b71 | other |
| 48533 | 2019-11-01 13:43:32.293463 | tips_show | 87b9b9a1-e7c1-4834-a43e-4510f177f3f9 | yandex |
| 49010 | 2019-10-22 13:10:44.397941 | tips_show | 39157e8e-2f0f-421d-839e-0cf216bd783d | |
| 49748 | 2019-10-26 14:00:53.427140 | contacts_show | e387d029-59eb-41b9-9be5-5548389c079c | |
| 66803 | 2019-10-29 21:16:52.427649 | tips_show | 07e18551-bea5-45f8-99bf-69ed821e54be | other |
Теперь изучим подробнее, какая информация нам доступна.
Посмотрим, данными за какой временной период мы обладаем, заодно проверим, точно ли мы распологаем данными от 7 октября 2019 года.
print('Минимальная дата:', dataset['event_time'].min().strftime("%d-%m-%Y, %H:%M:%S"),
'\nМаксимальная дата:', dataset['event_time'].max().strftime("%d-%m-%Y, %H:%M:%S"))
Минимальная дата: 07-10-2019, 00:00:00 Максимальная дата: 03-11-2019, 23:58:12
У нас есть информация о действиях, которые совершали пользователи в течение 4 недель - с 7 октября по 3 ноября 2019 года. Теперь оценим, полные ли данные во все представленные дни.
fig, ax = plt.subplots()
dataset['event_time'].hist(bins=28, figsize=(18,5), grid=False, ax=ax,)
plt.xlabel('Дата')
ax.xaxis.set_major_locator(mdates.DayLocator(interval=1))
plt.setp(ax.xaxis.get_majorticklabels(), rotation=-45 , ha="left", rotation_mode="anchor")
plt.ylabel('Количество событий')
plt.title('Распределение событий по датам'+'\n', color='SteelBlue', fontsize=20);
plt.show()
Да, за каждый день у нас есть данные. По графику можно отметить небольшие колебания активности пользователей по дням недели. Ближе к концу недели пользователи не так охотно совершают действия. Также можно увидеть подъем совершаемых действий к концу месяца. К сожалению трудно сказать, что могло повлиять на пользователей в это время.
print('Общее количество пользователей:', dataset['user_id'].nunique())
print('Общее число событий:', len(dataset),
'\nЧисло уникальных событий:', dataset['event_name'].nunique())
print('Число источников пользователей:',dataset['source'].nunique())
Общее количество пользователей: 4293 Общее число событий: 74197 Число уникальных событий: 9 Число источников пользователей: 3
Итак, у нас есть 4293 уникальных пользователя. Все эти пользователи за 4 недели исследования совершили 74197 событий. Всего пользователи совершили 9 уникальных событий. Также, пользователи пришли из 3 источников - двух крупных и многих небольших, которые объединены в одну группу "Другие".
Оценим, сколько событий в среднем совершал каждый пользователь отдельно.
events_per_user = dataset.pivot_table(index='user_id',
values='event_name',
aggfunc='count')
print('В среднем на пользователя приходится {} событий'.format(
round(events_per_user['event_name'].mean())))
display(events_per_user.describe())
fig, axes = plt.subplots(1, 2, figsize=(15, 5), sharey=False);
plt.figure(figsize=(10,8));
fig.suptitle('Количество событий на пользователя', color='SteelBlue', fontsize=20);
sns.boxplot(ax=axes[0],
data = events_per_user,
y='event_name',
notch=True,
showcaps=False,
flierprops={'marker': 'x'},
boxprops={'facecolor': 'cadetblue'},
medianprops={'color': 'r', 'linewidth': 2}).set(ylabel='Количество событий');
sns.boxplot(ax=axes[1],
data = events_per_user,
y='event_name',
notch=True,
showcaps=False,
flierprops={'marker': 'x'},
boxprops={'facecolor': 'cadetblue'},
medianprops={'color': 'r', 'linewidth': 2}).set(ylabel='Количество событий');
axes[1].set_ylim(0,37);
plt.show();
В среднем на пользователя приходится 17 событий
| event_name | |
|---|---|
| count | 4293.000000 |
| mean | 17.283252 |
| std | 29.130677 |
| min | 1.000000 |
| 25% | 5.000000 |
| 50% | 9.000000 |
| 75% | 17.000000 |
| max | 478.000000 |
<Figure size 1000x800 with 0 Axes>
В среднем каждый пользователь совершал от 5 до 17 событий за исследуемый промежуток времени. Медиана - 9 событий на пользователя. Однако, есть пользователи, которые совершали до 478 событий. Возможно это кто-то искал что-то конкретное, и потратил весь месяц на поиски и общение с продавцами. А может кто-то хотел полностью обставить свою квартиру с нуля, в надежде сделать это подешевле. К сожалению здесь трудно сказать, насколько это значение является выбивающимся.
Проведём анализ событий приложения, которые представлены в нашем датасете.
ax = dataset['event_name'].value_counts(ascending=True).plot(kind='barh',
xlabel='Событие',
figsize=(15,8))
for i in ax.containers:
ax.bar_label(i,label_type='edge')
ax.set_xlabel('Количество событий')
ax.set_title('События, которые совершают пользователи'+'\n',
color='SteelBlue', fontsize=20);
tips_show - чуть больше 40 тысяч, что составляет больше половины от всех совершенных действий. Ни одно другое действие не совершалось хотябы с приблизительной частотой. К сожалению, у нас нет данных о возможностях и тонкостях работы приложения, но похоже, что оно просто заваливает пользователей рекомендациями.tips_click - просмотр рекомендованного объявления находится на предпоследнем месте среди всех совершаемых действий. Возможно создателям приложения стоит обратить внимание на настройку рекомендаций.photos_show, вполне оправданно, ведь фотографий у одного объявления обычно бывает несколько, и с их помощью можно лучше убедиться в том, что вещь соответствует искомым параметрам.contacts_call, здесь также трудно сказать в чем проблема, возможно пользователи смотрели контакты продавца, копировали и просто не совершали звонки через само приложение, а переходили в системное приложение телефона.С общим количеством событий разобрались, теперь стоит взглянуть, сколько пользователей совершали каждое из этих событий за исследуемый промежуток времени.
user_logs = (dataset.pivot_table(index='event_name',
values='user_id',
aggfunc='nunique')
.sort_values(by='user_id',ascending=False))
#Посмотрим, сколько процентов пользователей хотябы раз совершали действие
user_logs['at_least_1_time_%'] = round((user_logs['user_id']/
dataset['user_id'].nunique())*100,2)
user_logs = user_logs.reset_index()
display(user_logs)
plt.figure(figsize=(15,8))
(sns.barplot(data = user_logs, orient='h', y='event_name', x='user_id', color='Green')
.set(xlabel='Количество пользователей', ylabel='Событие'))
plt.title('Сколько пользователей совершали события'+'\n',
color='SteelBlue', fontsize=20)
plt.show()
| event_name | user_id | at_least_1_time_% | |
|---|---|---|---|
| 0 | tips_show | 2801 | 65.25 |
| 1 | search | 1666 | 38.81 |
| 2 | map | 1456 | 33.92 |
| 3 | photos_show | 1095 | 25.51 |
| 4 | contacts_show | 981 | 22.85 |
| 5 | advert_open | 751 | 17.49 |
| 6 | favorites_add | 351 | 8.18 |
| 7 | tips_click | 322 | 7.50 |
| 8 | contacts_call | 213 | 4.96 |
search - его совершило 1666 пользователей.map- 1456 пользователей.contacts_call - 213 пользователейНас интересует, как же пользователи ведут себя в приложении. Для этого нам необходимо посмотреть когда и какие действия они совершают. Попробуем определить, как долго пользователи впринципе проводят в приложении.
dataset = dataset.sort_values(['user_id','event_time']).reset_index(drop=True)
dataset['time_dif'] = (dataset.groupby('user_id')['event_time']
.diff(1).fillna(value=dt.timedelta(seconds = 0)))
dataset['time_dif'].quantile([.75,.90,.91,.92,.93,.94,.95,.99])
0.75 0 days 00:02:47.395096 0.90 0 days 00:14:01.701345800 0.91 0 days 00:20:02.879849240 0.92 0 days 00:33:46.308574200 0.93 0 days 01:05:43.862410839 0.94 0 days 02:32:08.823209039 0.95 0 days 07:06:28.640042799 0.99 4 days 15:04:05.563057072 Name: time_dif, dtype: timedelta64[ns]
Мы рассмотрели, какие перерывы между совершениями событий делают пользователи, чтобы определить максимальное время сессии - времени, которое пользователь провел в приложении без выхода из профиля или перехода приложения в "спящий режим".
Попробуем взять за отсечку бездействия 3 часа.
gap = (dataset.groupby('user_id')['event_time'].diff() > pd.Timedelta('3Hour')).cumsum()
dataset['session_id'] = dataset.groupby(['user_id', gap], sort=False).ngroup() + 1
dataset.head()
| event_time | event_name | user_id | source | time_dif | session_id | |
|---|---|---|---|---|---|---|
| 0 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:00:00 | 1 |
| 1 | 2019-10-07 13:40:31.052909 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:00:45.063550 | 1 |
| 2 | 2019-10-07 13:41:05.722489 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:00:34.669580 | 1 |
| 3 | 2019-10-07 13:43:20.735461 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:02:15.012972 | 1 |
| 4 | 2019-10-07 13:45:30.917502 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:02:10.182041 | 1 |
Итак, мы разделили все события пользователей на сессии, посмотрим, какое количество сессий приходится в среднем на пользователей и сколько всего сессий.
print('Общее количество сессий:', dataset['session_id'].nunique())
sessions_per_user = dataset.pivot_table(index='user_id',
values='session_id',
aggfunc='nunique')
fig, axes = plt.subplots(1, 2, figsize=(15, 5), sharey=False)
fig.suptitle('Количество сессий на пользователя', color='SteelBlue', fontsize=20)
sns.boxplot(ax=axes[0],
data = sessions_per_user,
y='session_id',
notch=True,
showcaps=False,
flierprops={'marker': 'x'},
boxprops={'facecolor': 'cadetblue'},
medianprops={'color': 'r', 'linewidth': 2}).set(ylabel='Количество сессий')
sns.boxplot(ax=axes[1],
data = sessions_per_user,
y='session_id',
notch=True,
showcaps=False,
flierprops={'marker': 'x'},
boxprops={'facecolor': 'cadetblue'},
medianprops={'color': 'r', 'linewidth': 2}).set(ylabel='Количество сессий')
axes[1].set_ylim(0,5)
plt.show()
Общее количество сессий: 8613
Пользователи совершили 8613 сессий. В среднем пользователи совершали 1-2 сессии. Однако, есть пользователи, которые успели совершить около 40 сессий за исследуемое время.
Теперь приступим к определению сценариев событий, которые совершали пользователи во время своих сессий. Для этого сгруппируем наш датафрейм по уникальным пользователям и уникальным сессиям, которые совершали пользователи.
#Получаем датафрейм в разрезе по пользователям и сессиям, с датами начала и конца сессий,
#чтобы получить длительность
#А также количеством совершенных действий и списком уникальных событий,
#совершенных во время сессии, и источник пользователя
sessions = dataset.groupby(['user_id','session_id']).agg({'event_time':['first', 'last'],
'event_name':['unique','count'],
'source':'first'}).reset_index()
#Переименуем столбцы, из-за мультииндексации
sessions.columns = ['user_id',
'session_id',
'session_start',
'session_end',
'path',
'event_count',
'source']
#получим длительность сессий в секундах (чтобы проще было оценивать различия в длительности)
sessions['duration'] = (sessions['session_end'] - sessions['session_start']).dt.total_seconds()
#Получим колонку с буллевыми значениями, чтобы разделить сессии на те,
#в которых есть целевое действие и те, в которых нет
#Для этого напишем функцию
def check_events(event_list):
''' Функция проверяет, есть ли в списке событий целевое событие contacts_show
и возвращает буллево значение '''
if 'contacts_show' in event_list:
return True
else:
return False
#Применим функцию для создания колонки
sessions['target_action'] = sessions['path'].apply(check_events)
sessions
| user_id | session_id | session_start | session_end | path | event_count | source | duration | target_action | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 | 2019-10-07 13:39:45.989359 | 2019-10-07 13:49:41.716617 | [tips_show] | 9 | other | 595.727258 | False |
| 1 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2 | 2019-10-09 18:33:55.577963 | 2019-10-09 18:42:22.963948 | [map, tips_show] | 4 | other | 507.385985 | False |
| 2 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 3 | 2019-10-21 19:52:30.778932 | 2019-10-21 20:07:30.051028 | [tips_show, map] | 14 | other | 899.272096 | False |
| 3 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 4 | 2019-10-22 11:18:14.635436 | 2019-10-22 11:30:52.807203 | [map, tips_show] | 8 | other | 758.171767 | False |
| 4 | 00157779-810c-4498-9e05-a1e9e3cedf93 | 5 | 2019-10-19 21:34:33.849769 | 2019-10-19 21:59:54.637098 | [search, photos_show] | 9 | yandex | 1520.787329 | False |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 8608 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 8609 | 2019-10-30 11:31:45.886946 | 2019-10-30 11:31:45.886946 | [tips_show] | 1 | 0.000000 | False | |
| 8609 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 8610 | 2019-11-01 00:24:31.162871 | 2019-11-01 00:24:53.473219 | [tips_show] | 2 | 22.310348 | False | |
| 8610 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 8611 | 2019-11-02 01:16:48.947231 | 2019-11-02 01:16:48.947231 | [tips_show] | 1 | 0.000000 | False | |
| 8611 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 8612 | 2019-11-02 18:01:27.094834 | 2019-11-02 19:30:50.471310 | [tips_show, contacts_show] | 6 | 5363.376476 | True | |
| 8612 | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 8613 | 2019-11-03 14:32:55.956301 | 2019-11-03 16:08:25.388712 | [tips_show, contacts_show] | 29 | 5729.432411 | True |
8613 rows × 9 columns
Теперь определим, какие последовательности событий чаще всего встречались в нашем датасете и исключим те сесси, которые начинались с целевого события. Такое возможно, но нас интересует, что пользователи делают прежде, чем совершают просмотр контактов продавца. Да и нам неизвестно как устроен интерфейс приложения и нет другой информации, которая помогла бы лучше узнать поведение пользователей.
#Получим только те сессии, где в списке событий есть целевое действие
target_sessions = sessions.query('target_action == True').reset_index()
#Выведем все возможные списки событий в лист, чтобы посчитать повторяющиеся
events = target_sessions['path'].tolist()
#Так как списки событий хранятся в виде массива, с ними сложно работать, обратимся к функции,
#чтобы избавиться от массива
def convert_name(string):
''' Функция проверяет, есть ли в строке искомое значение (название события)
и возвращает его аббревиатуру '''
if string == 'advert_open':
return 'ao'
if string == 'photos_show':
return 'ps'
if string == 'tips_show':
return 'ts'
if string == 'tips_click':
return 'tp'
if string == 'contacts_show':
return 'cs'
if string == 'contacts_call':
return 'cc'
if string == 'map':
return 'mp'
if string == 'search':
return 'sc'
if string == 'favorites_add':
return 'fa'
#создадим пустой список, который будет получать список аббревиатур не в виде массива
event_list = []
#Для этого пройдёмся по всем массивам(последовательностям) в листе events
for array in events:
#И получим все элементы этих массивов и разделим на строки
event_elements = []
event_string = ''
#Все названия из массива сделаем аббревиатурами с помощью нашей функции
#и разделим их нижним подчеркиванием.
#Получим строку с аббревиатурами и передадим в пустой лист, который создали ранее
for element in array:
event_elements.append(convert_name(element))
event_string = '_'.join(event_elements)
event_list.append(event_string)
#event_list
#Добавим переработанный список массивов в качестве столбца в наш датасет целевых сессий
target_sessions['path_abb'] = pd.Series(event_list)
#Наконец получим датафрейм с популярными последовательностями событий
event_counts = target_sessions['path_abb'].value_counts().to_frame().reset_index()
event_counts = event_counts.rename(columns= {'index' : 'event_names'})
#print(event_counts)
#Получим только те последовательности, где целевое событие не является первым
event_counts = event_counts[event_counts['event_names'].str.contains('\w(cs)')]
event_counts.head(15)
| event_names | path_abb | |
|---|---|---|
| 0 | ts_cs | 262 |
| 2 | mp_ts_cs | 81 |
| 4 | ps_cs | 75 |
| 5 | sc_cs_cc | 51 |
| 7 | sc_ps_cs | 46 |
| 8 | sc_cs | 45 |
| 9 | ps_cs_cc | 38 |
| 11 | ts_cs_mp | 31 |
| 12 | sc_ts_cs | 31 |
| 14 | ts_mp_cs | 25 |
| 15 | ts_cs_tp | 24 |
| 16 | mp_cs_ts | 19 |
| 17 | sc_ps_cs_cc | 17 |
| 18 | ts_tp_cs | 15 |
| 19 | sc_cs_ts | 13 |
Теперь, чтобы построить воронки, нам необходимо отметить, какие уникальные действия совершали пользователи, чтобы строить воронку только по тем пользователям, которые точно совершали все события из популярных последовательностей - чтобы не получить веретено вместо воронки.
#Усовершенствуем наш датасет, чтобы было проще отбирать людей, которые совершали все действия
#из популярных сценариев для построения воронок
def get_event_string(lst):
''' Создает строку с уникальными действиями пользователей в виде сокращений, если пользователь
хотябы раз совершал данное действие'''
list = []
for event in lst:
list.append(convert_name(event))
return '_'.join(list)
# Для каждого пользователя получим строку с уникальными совершенными событиями
user_event_string = dataset.groupby('user_id')['event_name'].unique().apply(get_event_string)
# Объединяем результат с исходным датафреймом
dataset['user_events'] = dataset['user_id'].map(user_event_string)
dataset
| event_time | event_name | user_id | source | time_dif | session_id | user_events | |
|---|---|---|---|---|---|---|---|
| 0 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:00:00 | 1 | ts_mp |
| 1 | 2019-10-07 13:40:31.052909 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:00:45.063550 | 1 | ts_mp |
| 2 | 2019-10-07 13:41:05.722489 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:00:34.669580 | 1 | ts_mp |
| 3 | 2019-10-07 13:43:20.735461 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:02:15.012972 | 1 | ts_mp |
| 4 | 2019-10-07 13:45:30.917502 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | other | 0 days 00:02:10.182041 | 1 | ts_mp |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 74192 | 2019-11-03 15:51:23.959572 | tips_show | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 0 days 00:00:27.886483 | 8613 | ts_mp_cs | |
| 74193 | 2019-11-03 15:51:57.899997 | contacts_show | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 0 days 00:00:33.940425 | 8613 | ts_mp_cs | |
| 74194 | 2019-11-03 16:07:40.932077 | tips_show | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 0 days 00:15:43.032080 | 8613 | ts_mp_cs | |
| 74195 | 2019-11-03 16:08:18.202734 | tips_show | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 0 days 00:00:37.270657 | 8613 | ts_mp_cs | |
| 74196 | 2019-11-03 16:08:25.388712 | tips_show | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 0 days 00:00:07.185978 | 8613 | ts_mp_cs |
74197 rows × 7 columns
Мы получили датасет, где для каждого пользователя есть список уникальных действий, которые пользователь совершал хотябы раз. Данный список нам еще пригодится в дальнейшем.
Теперь мы можем построить воронки событий по нашим популярным сценариям. Отберем 6 первых популярных сценариев, так как частота их встречаемости близка к 50 или выше.
#Зададим параметр топ-последовательностей событий
chart_num = 6
#А теперь обратно получим список событий, чтобы построить воронки
#Для этого создадим функцию, которая из аббревиатур получает полные названия событий
def unroll_code(string):
''' Функция проверяет налчие в строке определенной аббревиатуры
и возвращает полное название события '''
if string == 'ao':
return 'advert_open'
if string == 'ps':
return 'photos_show'
if string == 'ts':
return 'tips_show'
if string == 'tp':
return 'tips_click'
if string == 'cs':
return 'contacts_show'
if string == 'cc':
return 'contacts_call'
if string == 'mp':
return 'map'
if string == 'sc':
return 'search'
if string == 'fa':
return 'favorites_add'
#Напишем функцию, которая получает список последовательностей
def unroll_list(string):
''' Функция создаёт массив(список) последовательностей
из строки с аббревиатурами и создаёт лист списков '''
string_list = []
string = string.split('_')
for element in string:
string_list.append(unroll_code(element))
return string_list
#Создадим пустой список, который будет получать списки из датафрейма с популярными событиями
funnel_data = []
#Создаем лист с топом последовательностей событий
popular_funnel = event_counts['event_names'].head(chart_num).tolist()
#Разворачиваем последовательности из аббревиатур и добавляем их в пустой список
for funnel in popular_funnel:
funnel_data.append(unroll_list(funnel))
#Зададим маски, чтобы считать количество пользователей на каждом этапе с учетом предыдущего
mask_ts = dataset['user_events'].str.contains('ts')
mask_mp = dataset['user_events'].str.contains('mp')
mask_ps = dataset['user_events'].str.contains('ps')
mask_cs = dataset['user_events'].str.contains('cs')
mask_sc = dataset['user_events'].str.contains('sc')
mask_cc = dataset['user_events'].str.contains('cc')
mask_ts = dataset['user_events'].str.contains('ts')
mask_tp = dataset['user_events'].str.contains('tp')
mask_fa = dataset['user_events'].str.contains('fa')
#Функция, чтобы получать маску
def decode_mask(string):
return f'@mask_{string}'
#Функция, чтобы создавать маску для последующих этапов
def build_mask(string):
ev_str = string.split('_')
result = ''
for i in range(len(ev_str)):
if i == 0:
result = decode_mask(ev_str[i])
else:
result = result + ' & ' + decode_mask(ev_str[i])
return result
#Получаем число уникальных пользователей для каждого этапа
#С учётом участия в предыдущих этапах
funnel_x_all = []
for event_chain in popular_funnel:
events = event_chain.split('_')
mask_list = []
en_list = []
for event in events:
mask_list.append(event)
string = '_'.join(mask_list)
res_string = build_mask(string)
event_number = dataset.query(f'{res_string}')['user_id'].nunique()
en_list.append(event_number)
funnel_x_all.append(en_list)
#Построим воронки событий на одном графике
#Для этого определим количество субграфиков на графике и получим количество строк и столбцов
if len(funnel_data) % 2 == 0:
row_number = 2
col_number = int(len(funnel_data) / 2)
else:
row_number = 2
col_number = int((len(funnel_data) // 2) + 1)
#Строим фигуру с субграфиками, которая получает рассчитываемые координаты
fig = make_subplots(rows=row_number, cols=col_number, horizontal_spacing=0.11)
#И первое значение списка популярных последовательностей для воронки
k = 0
#Которая будет в зависимости от значения из списка последовательностей
#будет получать последовательность для воронки
#И строить график в соответствующем субграфике
#И продолжать так для всех последующих воронок
for i in range(1,row_number+1):
for j in range(1,col_number+1):
if k != len(funnel_data):
fig.add_trace(go.Funnel(
y = funnel_data[k],
x = funnel_x_all[k],
textposition = "inside",
textinfo = "value+percent previous" ),
row=i, col=j)
k = k + 1
else:
pass
#Зададим параметры для всего графика
fig.update_layout(
autosize=True,
showlegend = False,
title='Воронки событий по популярным паттернам поведения пользователей',
yaxis_title='События',
font=dict(
family="arial",
size=12,
color="darkslateblue"
))
fig.show()
Самая большая конверсия в целевое действие отмечается в следующих паттернах:
Просто показ рекомендаций приводит к совершению целевого действия только в 10% случаев. Также, стоит отметить, что не всегда пользователям удается что-то найти.
Теперь посмотрим, сколько вообще сессий можно считать успешными.
plt.figure(figsize=(8,8))
sessions['target_action'].value_counts().plot.pie(autopct='%.0f%%',
ylabel='',
fontsize=14,
labels=None)
plt.legend(labels=['Не совершалось целевое действие','Совершалось целевое действие'],
loc='upper center')
plt.title('Соотношение сессий по типу', color='SteelBlue', fontsize=20)
plt.show()
Только в 17% сессий пользователи совершили целевое действие. Похоже, что не так много пользователей могут найти что-то интересующее их.
Теперь оценим, есть ли различия в длительности между успешными и неуспешными сессиями.
fig, axes = plt.subplots(1, 2, figsize=(15, 5), sharey=False);
fig.suptitle('Соотношение длительности сессии с видом сессии', color='SteelBlue', fontsize=20);
sns.boxplot(ax=axes[0],
data=sessions,
x='target_action',
y='duration').set(xlabel='Совершение целевого действия',
ylabel='Продолжительность сессии в секундах')
axes[0].set_xticklabels(['Нет','Да'])
sns.boxplot(ax=axes[1],
data=sessions,
x='target_action',
y='duration').set(xlabel='Совершение целевого действия',
ylabel='Продолжительность сессии в секундах')
axes[1].set_ylim(0,11000)
axes[1].set_xticklabels(['Нет','Да'])
plt.show();
Сессии, которые приводят к совершению целевого действия в среднем длятся дольше, чем сессии, где не совершается целевое действие. Медиана длительности таких сессий составляет около получаса. В среднем, успешные сессии не длятся более 1 часа. Однако, в обоих случаях встречаются сессии, которые длятся запредельно долго. Этому может быть несколько причин:
Посмотрим, какие еще есть различия между пользователями, которые совершали целевое действие, и не совершали целевое действие. А именно - проверим, отличаются ли доли совершаемых событий в двух группах пользователей.
ta = dataset['user_events'].str.contains('cs')
no_ta = ~dataset['user_events'].str.contains('cs')
target_group = dataset.query('@ta')['event_name'].value_counts().to_frame().sort_values(by='event_name')
target_group['share'] = round((target_group['event_name']/target_group['event_name'].sum())*100)
no_target_group = dataset.query('@no_ta')['event_name'].value_counts().to_frame().sort_values(by='event_name')
no_target_group['share'] = round((no_target_group['event_name']/no_target_group['event_name'].sum())*100)
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
ax1 = target_group.plot(kind='barh', y='share',legend=False, ax=axes[0], xlabel='Событие')
ax1.set_title('Среди пользователей, совершавших целевое действие')
ax1.set_xlabel('Доля от всех событий,%')
for i in ax1.containers:
ax1.bar_label(i,fmt='%1.f%%',label_type='edge')
ax2 = no_target_group.plot(kind='barh', y='share',legend=False, ax=axes[1])
ax2.set_title('Среди пользователей, не совершавших целевое действие')
ax2.set_xlabel('Доля от всех событий,%')
for i in ax2.containers:
ax2.bar_label(i,fmt='%1.f%%',label_type='edge')
fig.suptitle('Относительная частота событий'+'\n', fontsize=20, color='SteelBlue')
plt.show()
Судя по графикам, в обеих группах все события соотносятся друг с другом одинаково:
tips_show,tips_click,Единственное отличие - пользователи, которые не совершают целевое действие - просмотр контактов, не совершают событие contacts_call. Логично предположить, что это связано с интерфейсом приложения - нельзя позвонить по номеру, который не посмотрел.
plt.figure(figsize=(15,8))
sns.barplot(data=(dataset
.pivot_table(index='source',values='user_id',aggfunc='nunique')
.sort_values(by='user_id',ascending=False)
.reset_index()), x='source', y='user_id').set(xlabel='Источник',
ylabel='Количество пользователей')
plt.title('Распределение пользователей по источнику привлечения'+'\n',
color='SteelBlue',fontsize=20)
plt.show()
Больше всего людей пришло из Яндекс'а, меньше всего людей пришло из Google. Было бы интересно также взглянуть на состав источников в группе others, но этой информации у нас нет. Однако, из нескольких источников людей пришло больше, чем из Google.
Теперь посмотрим, пользователи из какого источника охотнее совершали целевое действие.
plt.figure(figsize=(15,8))
sns.barplot(data=(dataset
.query('event_name == "contacts_show"')
.pivot_table(index='source',values='user_id',aggfunc='nunique')
.sort_values(by='user_id',ascending=False).reset_index()),
x='source',
y='user_id').set(xlabel='Источник',ylabel='Количество пользователей')
plt.title('Количество пользователей, совершивших целевое событие в разрезе по источникам'+'\n',
color='SteelBlue', fontsize=20)
plt.show()
В совершении пользователями целевого действия также лидирует Яндекс. На этот раз Google занимает второе место. Меньше всего целевое действие совершали пользователи из других источников, не смотря на то, что от них пользователей пришло больше, чем из Google. Однако, без расчета конверсии делать выводы о успешности источников пока рано. Конверсию рассмотрим далее.
У нас есть две группы пользователей: те, кто только просматривают рекомендуемые объявления, и те, кто просматривают объявления и переходят по ним. Нас интересует, насколько эта разница в поведении влияет на конверсию и можно ли это как-то использовать для увеличения конверсии в приложении в целом.
Для этого проведем проверку следующей гипотезы:
Проверять гипотезу мы будем сравнением долей.
def tips_groups(string):
''' Функция проверяет, есть ли в списке событий совершенных пользователем искомое значение
и возвращает название группы '''
if ('ts' in string) and not ('tp' in string):
return 'only_show'
elif ('ts' in string) and ('tp' in string):
return 'show_click'
else:
return False
#Применим функцию для создания колонки
dataset['tips'] = dataset['user_events'].apply(tips_groups)
#dataset
#Теперь получим события для групп
groups = dataset.pivot_table(index='event_name',
values='user_id',
columns='tips',
aggfunc='nunique')
groups = groups.T
groups = groups.merge(dataset.pivot_table(index='tips',
values='user_id',
aggfunc='nunique'),on='tips')
groups = groups[['contacts_show','user_id']].drop([False],axis=0).reset_index()
groups['conversion'] = round(groups['contacts_show']/groups['user_id']*100)
display(groups)
plt.figure(figsize=(15,8))
sns.barplot(data=groups, x='tips', y='conversion').set(xlabel='Совершенные действия',
ylabel='Конверсия, %')
plt.title('Значение конверсии в зависимости от действий с рекомендациями'+'\n',
color='SteelBlue', fontsize=20)
plt.xticks(ticks=[0,1],labels=['Только просмотр','Просмотр и переход'])
plt.show()
| tips | contacts_show | user_id | conversion | |
|---|---|---|---|---|
| 0 | only_show | 425.0 | 2504 | 17.0 |
| 1 | show_click | 91.0 | 297 | 31.0 |
Разница между конверсией пользователей в этих двух группах различается почти в два раза. Теперь стоит оценить, насколько эта разница критична.
alpha = 0.05
trial_1 = groups['user_id'][0]
succes_1 =groups['contacts_show'][0]
trial_2 =groups['user_id'][1]
succes_2 =groups['contacts_show'][1]
p1 = succes_1/trial_1
p2 = succes_2/trial_2
p_combined = (succes_1 + succes_2) / (trial_1 + trial_2)
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trial_1+ 1/trial_2))
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
p-значение: 9.218316554537864e-09 Отвергаем нулевую гипотезу: между долями есть значимая разница
Разница конверсий имеет статистическую значимость, что не удивительно, ведь она достаточно велика. Похоже, что наиболее охотно совершают целевое действие пользователи, которые переходят на рекламное объявление. Это добавляет аргументов в пользу теории, что есть какая-то проблема в рекомендациях для пользователей.
Мы определили, что из источника Яндекс пришло больше всего пользователей, и больше всего пользователей совершали целевое действие. Но теперь стоит оценить конверсию у двух ведущих источников и сравнить, отличается ли она и имеет ли это статистическую значимость.
Для этого проведем проверку гипотез:
Для проверки воспользуемся методом сравнения долей, так как выборки отличаются. Тест будет двусторонним, так как нам нужно только проверить есть ли отличия и насколько они значимы.
source_groups = dataset.pivot_table(index='event_name',
values='user_id',
columns='source',
aggfunc='nunique')
source_groups = source_groups.T
source_groups = source_groups.merge(dataset.pivot_table(index='source',
values='user_id',
aggfunc='nunique'),on='source')
source_groups = source_groups[['contacts_show','user_id']].drop(['other'],axis=0).reset_index()
source_groups['conversion'] = round(source_groups['contacts_show']/source_groups['user_id']*100)
display(source_groups)
plt.figure(figsize=(15,8))
sns.barplot(data=source_groups, x='source', y='conversion').set(xlabel='Источник',
ylabel='Конверсия, %')
plt.title('Значение конверсии в зависимости от источника'+'\n',
color='SteelBlue', fontsize=20)
plt.show()
| source | contacts_show | user_id | conversion | |
|---|---|---|---|---|
| 0 | 275 | 1129 | 24.0 | |
| 1 | yandex | 478 | 1934 | 25.0 |
Различие между конверсией составляет 1%, теперь оценим, насколько значима эта разница.
alpha = 0.05
trials1 = source_groups['user_id'][0]
hits1 = source_groups['contacts_show'][0]
trials2 = source_groups['user_id'][1]
hits2 =source_groups['contacts_show'][1]
p1 = hits1/trials1
p2 = hits2/trials2
p_combined = (hits1 + hits2) / (trials1 + trials2)
difference = p1 - p2
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials1 + 1/trials2))
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
p-значение: 0.8244316027993777 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Что же, при всех отличиях между источниками, из которых пришли пользователи, их конверсия пользователей не отличается.
В ходе работы мы провели анализ пользовательских логов приложения "Ненужные вещи" за 4 недели - с 7 октября по 3 ноября 2019 года. Вот, какие выводы можно сделать из данных:
Какие закономерности можно отметить:
Какие особенности поведения прослеживаются у пользователей:
Чтобы повысить заинтересованность пользователя, стоит рассмотреть следующие варианты улучшения: